Skip to content

[backend:fix] Reject engine-unsupported UDF parameter and return types MCDB-100032 #closes - #155

Merged
kesmit13 merged 2 commits into
mainfrom
skumar/MCDB-100032-reject-unsupported-udf-types
Nov 4, 2026
Merged

kesmit13 merged 2 commits into
mainfrom
skumar/MCDB-100032-reject-unsupported-udf-types

Conversation

@ShubMishra

@ShubMishra ShubMishra commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

SingleStore rejects CREATE EXTERNAL FUNCTION statements that use DATE, TIME, DATETIME, TIMESTAMP, YEAR, or DECIMAL parameter or return types with ERROR 1706 (ER_MEMSQL_FEATURE_LOCKDOWN). Previously, singlestoredb accepted datetime.datetime, datetime.date, datetime.timedelta, and decimal.Decimal annotations (as well as explicit SQL type overrides for those types) and generated DDL that later failed during registration.

This change rejects those unsupported Python annotations and SQL type overrides upfront with a TypeError in signature.py (normalize_dtype(), sql_to_dtype(), and dtype_to_sql()), calls get_signature() at @udf decoration time (decorator.py) so invalid signatures fail immediately, and resolves string annotations in get_resolved_signature() to support from __future__ import annotations, including runtime detection of Masked parameters. Python date/time/decimal annotations remain allowed when paired with a supported explicit SQL override (such as @udf(args=dt.TEXT, returns=dt.TEXT)).

Test Plan

  • Added unit tests in singlestoredb/tests/test_udf.py and singlestoredb/tests/test_udf_future_annotations.py covering rejection at @udf decoration time and in get_signature() / signature_to_sql() for unsupported Python annotations, Optional[...] unions, explicit dt.* / SQL string overrides, and modules using from __future__ import annotations.
  • Ran pytest on the UDF test suite (test_udf*.py, test_function_url.py, test_plugin*.py) and pre-commit (flake8, autopep8, reorder-python-imports, add-trailing-comma, mypy).
  • Staging test: run run_udf_app() / publish a Python UDF notebook using datetime, date, timedelta, and Decimal annotations and confirm it fails immediately in the notebook with TypeError before emitting DDL.

Deployment Plan

  • Customer impact: Yes. Python UDFs using engine-unsupported date, time, or decimal types now fail immediately in the notebook with a TypeError instead of failing asynchronously with ERROR 1706 during database registration.
  • Rollout Plan: Release a new singlestoredb SDK version, then update the SDK version pin in Helios.
  • Rollback Plan: Revert the PR and release a new SDK version.
  • Rollback Tested: Yes.

Subscribers

JIRA Issues

Closes MCDB-100032


Note

Medium Risk
Breaking change for UDFs using date/time/decimal annotations or matching SQL overrides; behavior is intentional but affects customer notebooks at decoration time instead of async DB registration.

Overview
Python UDFs that annotated parameters or returns with datetime, date, time, timedelta, or Decimal (or explicit SQL overrides like DATE, DATETIME, DECIMAL) used to emit CREATE EXTERNAL FUNCTION DDL that SingleStore rejects with ER_MEMSQL_FEATURE_LOCKDOWN. This PR blocks those types upfront with TypeError in signature normalization and SQL dtype conversion, and documents the slimmer supported type map in ARCHITECTURE.md.

The @udf decorator now runs get_signature() when the function is decorated, so invalid signatures fail in the notebook before registration. Signature handling adds get_resolved_signature() to evaluate string annotations (including from __future__ import annotations), while leaving dtype strings like 'array[int64]' untouched; ASGI and the plugin registry use this for Masked parameter detection. Explicit supported SQL overrides (e.g. @udf(args=dt.TEXT, returns=dt.TEXT)) still work even when Python annotations mention unsupported types.

Tests cover decoration-time failures, SQL override rejection, postponed annotations, and illegal multi-list Table[...] return shapes.

Reviewed by Cursor Bugbot for commit a2d6da9. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread singlestoredb/functions/signature.py Resolved

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Previously supported annotations such as 'str' and 'bytes', along with normalized compound dtypes, now fail because normalized dtype strings are incorrectly parsed as SQL.

1 open issue
What changed in this PR

This PR adds Python SDK validation to reject date, time, and decimal UDF types before generating external-function DDL that SingleStore does not support.

Changes:

  • Validates annotations and explicit type overrides during decoration and signature extraction.
  • Adds rejection tests and coverage for supported SQL overrides.
  • Updates the architecture documentation’s type-mapping table.
File Description
singlestoredb/​tests/​test_udf.py Tests unsupported types and supported explicit overrides.
singlestoredb/​functions/​signature.py Adds unsupported-type checks and updates string-type handling.
singlestoredb/​functions/​decorator.py Validates annotations and overrides during decoration.
ARCHITECTURE.md Removes unsupported mappings and corrects the boolean mapping.

Review effort: Balanced

Comment thread singlestoredb/functions/signature.py Resolved
@ShubMishra
ShubMishra force-pushed the skumar/MCDB-100032-reject-unsupported-udf-types branch from 376234a to 31ab8dd Compare November 2, 2026 14:56
@ShubMishra ShubMishra changed the title Reject engine-unsupported UDF parameter and return types [backend:fix] Reject engine-unsupported UDF parameter and return types MCDB-100032 #closes Nov 2, 2026

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread singlestoredb/functions/signature.py Resolved
@ShubMishra
ShubMishra force-pushed the skumar/MCDB-100032-reject-unsupported-udf-types branch from 31ab8dd to 77d17ac Compare November 2, 2026 15:24
@kesmit13
kesmit13 requested a balanced review from Copilot November 2, 2026 17:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Generic-argument validation incorrectly treats NumPy shape metadata as an element type, causing supported fixed-size array annotations to fail during @udf decoration.

1 open issue
1 resolved since last review

Review effort: Balanced

Comment thread singlestoredb/functions/signature.py Resolved
@kesmit13

kesmit13 commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Possible fix: validate at decoration through get_signature, and resolve string annotations

Failing fast at decoration is the right goal. Two problems with how it's done now, and a suggested fix below. This is offered as a possible approach, not as changes pushed to this branch.

1. A second annotation walker that disagrees with get_signature

_validate_annotation_types duplicates the walk get_signature does, and the two disagree on error messages:

Annotation main (from get_signature) This PR (from the new walker)
untyped pd.Series pandas Series must have an element data type specified in the args= parameter of the @udf decorator unsupported type annotation: <class 'pandas.core.series.Series'>; use args/returns … (same for pl.Series / pa.Array)
Literal['a', 'b'] unsupported type annotation: typing.Literal['a', 'b'] unrecognized data type: a
Annotated[int, 'meta'] unsupported type annotation: typing.Annotated[int, 'meta'] unrecognized data type: meta

Literal and Annotated metadata gets treated as a dtype string because the walker recurses into typing.get_args() of every generic.

Suggestion: have decorate() call get_signature(wrapped) and delete _validate_annotation_types, validate_type_overrides and validate_func_annotations. Errors at decoration are then exactly the errors registration would raise. get_signature already applies the date/time/decimal rejections this PR adds to normalize_dtype / sql_to_dtype / dtype_to_sql, and it already lets a supported override cover a date annotation.

2. Modules using from __future__ import annotations can no longer be imported

validate_func_annotations uses inspect.signature(func), so under postponed evaluation every annotation is a string. Strings go to dtype_to_sql, which only knows dtype names, so even def f(x: int) -> int raises unrecognized data type: int when the module is imported. get_signature already failed on these modules on main (with KeyError), but the module still imported. With this PR the import itself fails.

Suggestion: resolve string annotations before building the signature. inspect.signature(eval_str=True) needs Python 3.10, and the package supports 3.9. typing.get_type_hints turns x: int = None into Optional[int] on 3.9 and 3.10, which would change the generated SQL, and it fails on dtype strings. So the patch evaluates each string annotation in the function's module namespace and leaves dtype strings such as 'str' and 'array[int64]' alone. A name that can't be resolved gives could not resolve type annotation 'Table[List[L]]': name 'L' is not defined.

Behaviour change to be aware of

All signature errors now fail at decoration, not only type errors. That includes argument-count mismatches with args= and illegal table shapes such as Table[List[int], List[str]]. Four assertions in test_udf.py / test_udf_returns.py decorated an invalid function and expected to_sql to raise. The patch moves the decoration inside assertRaises.

Verification (against 77d17ac)

  • Compared main, this PR, and this PR plus the patch across 70 annotation and override forms: scalars, numpy, NDArray, Masked, Table[...], NamedTuple, TypedDict, dataclass, pydantic, pandas/polars/pyarrow, SQL-string and dt.* overrides, and every date/time/decimal form.
    • The patch accepts and rejects exactly the same inputs as this PR, and emits identical SQL for the accepted ones.
    • The error messages in the table above are back to main's wording.
  • In all 43 rejected cases, the error raised at decoration has the same type and message as the error the ASGI app's get_signature(func, func_name=name) call raises later.
  • New test_udf_future_annotations.py: 6 tests that pass with the patch and fail on this PR.
  • test_udf*.py, test_function_url.py, test_plugin*.py: 134 passed, 4 skipped. tests/ext_funcs still imports. pre-commit is clean.
Patch (applies to 77d17ac with git apply)
diff --git a/singlestoredb/functions/decorator.py b/singlestoredb/functions/decorator.py
index 98a44d0d..909d741f 100644
--- a/singlestoredb/functions/decorator.py
+++ b/singlestoredb/functions/decorator.py
@@ -9,8 +9,7 @@ from typing import Union
 
 from . import utils
 from .dtypes import SQLString
-from .signature import validate_func_annotations
-from .signature import validate_type_overrides
+from .signature import get_signature
 
 
 ParameterType = Union[
@@ -97,7 +96,6 @@ def expand_types(args: Any) -> Optional[List[Any]]:
     else:
         raise TypeError(f'unrecognized type for parameter: {args}')
 
-    validate_type_overrides(expanded)
     return expanded
 
 
@@ -123,48 +121,31 @@ def _func(
         ).items() if v is not None
     }
 
-    # No func was specified, this is an uncalled decorator that will get
-    # called later, so the wrapper much be created with the func passed
-    # in at that time.
-    if func is None:
-        def decorate(func: UDFType) -> UDFType:
-            validate_func_annotations(
-                func,
-                has_args_override=expanded_args is not None,
-                has_returns_override=expanded_returns is not None,
-            )
-
-            if inspect.iscoroutinefunction(func):
-                async def async_wrapper(*args: Any, **kwargs: Any) -> UDFType:
-                    return await func(*args, **kwargs)  # type: ignore
-                async_wrapper._singlestoredb_attrs = _singlestoredb_attrs  # type: ignore
-                return functools.wraps(func)(async_wrapper)
+    def decorate(func: UDFType) -> UDFType:
+        if inspect.iscoroutinefunction(func):
+            async def async_wrapper(*args: Any, **kwargs: Any) -> UDFType:
+                return await func(*args, **kwargs)  # type: ignore
+            async_wrapper._singlestoredb_attrs = _singlestoredb_attrs  # type: ignore
+            wrapped: UDFType = functools.wraps(func)(async_wrapper)
 
-            else:
-                def wrapper(*args: Any, **kwargs: Any) -> UDFType:
-                    return func(*args, **kwargs)  # type: ignore
-                wrapper._singlestoredb_attrs = _singlestoredb_attrs  # type: ignore
-                return functools.wraps(func)(wrapper)
+        else:
+            def wrapper(*args: Any, **kwargs: Any) -> UDFType:
+                return func(*args, **kwargs)  # type: ignore
+            wrapper._singlestoredb_attrs = _singlestoredb_attrs  # type: ignore
+            wrapped = functools.wraps(func)(wrapper)
 
-        return decorate
+        # Build the signature now so that types the engine can't register
+        # fail at decoration time rather than when the DDL is executed.
+        get_signature(wrapped)
 
-    validate_func_annotations(
-        func,
-        has_args_override=expanded_args is not None,
-        has_returns_override=expanded_returns is not None,
-    )
+        return wrapped
 
-    if inspect.iscoroutinefunction(func):
-        async def async_wrapper(*args: Any, **kwargs: Any) -> UDFType:
-            return await func(*args, **kwargs)  # type: ignore
-        async_wrapper._singlestoredb_attrs = _singlestoredb_attrs  # type: ignore
-        return functools.wraps(func)(async_wrapper)
+    # No func was specified, this is an uncalled decorator that will get
+    # called later with the func to wrap.
+    if func is None:
+        return decorate
 
-    else:
-        def wrapper(*args: Any, **kwargs: Any) -> UDFType:
-            return func(*args, **kwargs)  # type: ignore
-        wrapper._singlestoredb_attrs = _singlestoredb_attrs  # type: ignore
-        return functools.wraps(func)(wrapper)
+    return decorate(func)
 
 
 def udf(
diff --git a/singlestoredb/functions/signature.py b/singlestoredb/functions/signature.py
index 290773df..3657ebcf 100644
--- a/singlestoredb/functions/signature.py
+++ b/singlestoredb/functions/signature.py
@@ -1185,6 +1185,48 @@ def vector_check(obj: Any) -> Tuple[Any, str]:
     return obj, 'scalar'
 
 
+def _is_dtype_str(annotation: str) -> bool:
+    """Is the string a dtype string (e.g. 'int64?', 'array[str]') not Python code?"""
+    dtype = annotation[:-1] if annotation.endswith('?') else annotation
+    dtype = dtype.replace('|null', '')
+    return (
+        dtype in sql_type_map
+        or dtype in unsupported_dtypes
+        or dtype.startswith(('array[', 'tuple['))
+    )
+
+
+def _resolve_annotation(annotation: Any, func: Callable[..., Any]) -> Any:
+    """
+    Evaluate a string annotation in the function's module namespace.
+
+    String annotations come from ``from __future__ import annotations`` or
+    quoted forward references. Dtype strings are left as-is.
+
+    """
+    if not isinstance(annotation, str) or _is_dtype_str(annotation):
+        return annotation
+    globalns = getattr(inspect.unwrap(func), '__globals__', {})
+    try:
+        return eval(annotation, globalns)
+    except Exception as exc:
+        raise TypeError(
+            f'could not resolve type annotation {annotation!r}: {exc}',
+        ) from exc
+
+
+def get_resolved_signature(func: Callable[..., Any]) -> inspect.Signature:
+    """Return the signature of ``func`` with string annotations evaluated."""
+    signature = inspect.signature(func)
+    return signature.replace(
+        parameters=[
+            p.replace(annotation=_resolve_annotation(p.annotation, func))
+            for p in signature.parameters.values()
+        ],
+        return_annotation=_resolve_annotation(signature.return_annotation, func),
+    )
+
+
 def get_masks(func: Callable[..., Any]) -> Tuple[List[bool], List[bool]]:
     """
     Get the list of masked parameters and return values for the function.
@@ -1202,8 +1244,9 @@ def get_masks(func: Callable[..., Any]) -> Tuple[List[bool], List[bool]]:
 
 
     """
-    params = inspect.signature(func).parameters
-    returns = inspect.signature(func).return_annotation
+    signature = get_resolved_signature(func)
+    params = signature.parameters
+    returns = signature.return_annotation
 
     ret_masks = []
     if typing.get_origin(returns) is Masked:
@@ -1242,7 +1285,7 @@ def get_signature(
     Dict[str, Any]
 
     '''
-    signature = inspect.signature(func)
+    signature = get_resolved_signature(func)
     args: List[Dict[str, Any]] = []
     returns: List[Dict[str, Any]] = []
 
@@ -1258,8 +1301,6 @@ def get_signature(
         elif p.kind == inspect.Parameter.VAR_KEYWORD:
             raise TypeError('variable keyword arguments are not supported')
 
-    # TODO: Use typing.get_type_hints() for parameters / return values?
-
     # Generate the parameter type and the corresponding SQL code for that parameter
     args_schema: List[ParamSpec] = []
     args_data_formats = []
@@ -1536,62 +1577,6 @@ def dtype_to_sql(
     return f'{sql_type}{nullable}{default_clause}'
 
 
-def _validate_annotation_types(annotation: Any) -> None:
-    """Validate that a parameter or return annotation contains no unsupported types."""
-    annotation = utils.resolve_type_alias(annotation)
-    if (
-        annotation is inspect.Parameter.empty
-        or annotation is None
-        or annotation is type(None)  # noqa: E721
-        or annotation is Any
-        or annotation is Ellipsis
-        or annotation in (list, tuple, dict)
-        or utils.is_dataframe(annotation)
-    ):
-        return
-
-    if isinstance(annotation, str):
-        dtype_to_sql(annotation)
-        return
-
-    if typing.get_origin(annotation) is not None:
-        for arg in typing.get_args(annotation):
-            _validate_annotation_types(arg)
-        return
-
-    if is_composite_type(annotation):
-        for pspec in get_colspec([annotation]):
-            _validate_annotation_types(pspec.dtype)
-        return
-
-    for item in simplify_dtype(annotation):
-        normalize_dtype(item)
-
-
-def validate_type_overrides(overrides: Optional[List[Any]]) -> None:
-    """Validate explicit args/returns type overrides passed to @udf."""
-    if not overrides:
-        return
-    for pspec in get_colspec(overrides):
-        _validate_annotation_types(pspec.dtype)
-
-
-def validate_func_annotations(
-    func: Callable[..., Any],
-    *,
-    has_args_override: bool = False,
-    has_returns_override: bool = False,
-) -> None:
-    """Validate parameter and return type annotations on a UDF callable."""
-    sig = inspect.signature(func)
-    if not has_args_override:
-        for param in sig.parameters.values():
-            if param.annotation is not inspect.Parameter.empty:
-                _validate_annotation_types(param.annotation)
-    if not has_returns_override and sig.return_annotation is not inspect.Signature.empty:
-        _validate_annotation_types(sig.return_annotation)
-
-
 def signature_to_sql(
     signature: Dict[str, Any],
     url: Optional[str] = None,
diff --git a/singlestoredb/tests/test_udf.py b/singlestoredb/tests/test_udf.py
index 59380f6b..d3ca8bfc 100755
--- a/singlestoredb/tests/test_udf.py
+++ b/singlestoredb/tests/test_udf.py
@@ -487,10 +487,9 @@ class TestUDF(unittest.TestCase):
         assert to_sql(foo) == '`foo`(`x` BIGINT NOT NULL) RETURNS SMALLINT NOT NULL'
 
         # Override multiple params with one type
-        @udf(args=dt.SMALLINT(nullable=False))
-        def foo(x: int, y: float, z: np.int8) -> int: ...
         with self.assertRaises(ValueError):
-            to_sql(foo)
+            @udf(args=dt.SMALLINT(nullable=False))
+            def foo(x: int, y: float, z: np.int8) -> int: ...
 
         # Override with list
         @udf(args=[dt.SMALLINT, dt.FLOAT, dt.CHAR(30)])
@@ -500,16 +499,14 @@ class TestUDF(unittest.TestCase):
             '`z` CHAR(30) NULL) RETURNS BIGINT NOT NULL'
 
         # Override with too short of a list
-        @udf(args=[dt.SMALLINT, dt.FLOAT])
-        def foo(x: int, y: float, z: str) -> int: ...
         with self.assertRaises(ValueError):
-            to_sql(foo)
+            @udf(args=[dt.SMALLINT, dt.FLOAT])
+            def foo(x: int, y: float, z: str) -> int: ...
 
         # Override with too long of a list
-        @udf(args=[dt.SMALLINT, dt.FLOAT, dt.CHAR(30), dt.TEXT])
-        def foo(x: int, y: float, z: str) -> int: ...
         with self.assertRaises(ValueError):
-            to_sql(foo)
+            @udf(args=[dt.SMALLINT, dt.FLOAT, dt.CHAR(30), dt.TEXT])
+            def foo(x: int, y: float, z: str) -> int: ...
 
         # Override with list
         @udf(args=[dt.SMALLINT, dt.FLOAT, dt.CHAR(30)])
diff --git a/singlestoredb/tests/test_udf_future_annotations.py b/singlestoredb/tests/test_udf_future_annotations.py
new file mode 100644
index 00000000..61b99bd5
--- /dev/null
+++ b/singlestoredb/tests/test_udf_future_annotations.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python
+# type: ignore
+"""UDF signatures in a module using postponed evaluation of annotations."""
+from __future__ import annotations
+
+import datetime
+import unittest
+from typing import List
+from typing import NamedTuple
+from typing import Optional
+
+import numpy as np
+import numpy.typing as npt
+
+from ..functions import dtypes as dt
+from ..functions import Masked
+from ..functions import signature as sig
+from ..functions import Table
+from ..functions import udf
+
+
+def to_sql(x):
+    out = sig.signature_to_sql(sig.get_signature(x))
+    out = out.split('EXTERNAL FUNCTION ')[1]
+    return out.split('AS REMOTE')[0].strip()
+
+
+class Row(NamedTuple):
+    a: int
+    b: Optional[str]
+
+
+class TestFutureAnnotations(unittest.TestCase):
+
+    def test_scalars(self):
+        @udf
+        def foo(x: Optional[int], y: float = 1.5) -> Optional[str]: ...
+        assert to_sql(foo) == \
+            '`foo`(`x` BIGINT NULL, `y` DOUBLE NOT NULL DEFAULT 1.5e0) ' \
+            'RETURNS TEXT NULL'
+
+    def test_masked_vectors(self):
+        @udf
+        def foo(
+            x: npt.NDArray[np.float32],
+        ) -> Masked[npt.NDArray[np.float32]]: ...
+        assert to_sql(foo) == '`foo`(`x` FLOAT NOT NULL) RETURNS FLOAT NULL'
+
+    def test_table(self):
+        @udf
+        def foo(x: int) -> Table[List[Row]]: ...
+        assert to_sql(foo) == \
+            '`foo`(`x` BIGINT NOT NULL) ' \
+            'RETURNS TABLE(`a` BIGINT NOT NULL, `b` TEXT NULL)'
+
+    def test_dtype_strings(self):
+        @udf
+        def foo(x: 'str') -> 'array[int64]': ...  # noqa: F821
+        assert to_sql(foo) == \
+            '`foo`(`x` TEXT NOT NULL) RETURNS ARRAY(BIGINT NOT NULL) NOT NULL'
+
+    def test_unsupported_types(self):
+        with self.assertRaisesRegex(TypeError, 'datetime.date'):
+            @udf
+            def foo(x: datetime.date) -> int: ...
+
+        @udf(args=dt.TEXT, returns=dt.TEXT)
+        def bar(x: datetime.date) -> datetime.date: ...
+        assert to_sql(bar) == '`bar`(`x` TEXT NULL) RETURNS TEXT NULL'
+
+    def test_unresolvable(self):
+        class Local(NamedTuple):
+            a: int
+
+        with self.assertRaisesRegex(TypeError, 'could not resolve.*Local'):
+            @udf
+            def foo(x: int) -> Table[List[Local]]: ...
diff --git a/singlestoredb/tests/test_udf_returns.py b/singlestoredb/tests/test_udf_returns.py
index f1daa58d..b4457219 100644
--- a/singlestoredb/tests/test_udf_returns.py
+++ b/singlestoredb/tests/test_udf_returns.py
@@ -138,17 +138,10 @@ class UDFResultsTest(unittest.TestCase):
             '`foo_h_`(`x` TEXT NOT NULL) RETURNS TABLE(`a` TEXT NOT NULL)'
 
         # Plain TVF using multiple lists -- Illegal!
-        @udf
-        def foo_h(x: str) -> Table[List[int], List[str]]:
-            return Table(list(range(3)), [x] * 3)
-
-        foo_h_out = foo_h('cat')
-
-        assert type(foo_h_out) is Table
-        assert foo_h_out == Table([0, 1, 2], ['cat', 'cat', 'cat'])
-
         with self.assertRaises(TypeError):
-            to_sql(foo_h)
+            @udf
+            def foo_h(x: str) -> Table[List[int], List[str]]:
+                return Table(list(range(3)), [x] * 3)
 
         # Plain TVF using lists of NamedTuples
         @udf
@@ -361,16 +354,10 @@ class UDFResultsTest(unittest.TestCase):
         )
 
         # Vectorized TVF using lists -- Illegal!
-        @udf
-        def foo_t(x: List[str]) -> Table[List[int], List[str]]:
-            return Table(list(range(len(x))), x)
-
-        foo_t_out = foo_t(['cat', 'dog', 'monkey'])
-
-        assert type(foo_t_out) is Table
-        assert foo_t_out == Table([0, 1, 2], ['cat', 'dog', 'monkey'])
         with self.assertRaises(TypeError):
-            to_sql(foo_t)
+            @udf
+            def foo_t(x: List[str]) -> Table[List[int], List[str]]:
+                return Table(list(range(len(x))), x)
 
         # Vectorized TVF using pandas Series
         @udf(args=Parameters, returns=TVFTuple)

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread singlestoredb/functions/signature.py Resolved
@ShubMishra
ShubMishra force-pushed the skumar/MCDB-100032-reject-unsupported-udf-types branch from 16926eb to f618905 Compare November 3, 2026 03:22

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f618905. Configure here.

Comment thread singlestoredb/functions/signature.py Resolved
@ShubMishra
ShubMishra force-pushed the skumar/MCDB-100032-reject-unsupported-udf-types branch from f618905 to 8f8fd55 Compare November 3, 2026 06:09
@kesmit13

kesmit13 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Dataclass TVFs still fail under from __future__ import annotations

Thanks for taking the earlier patch. One gap is left. In a module using postponed annotations, a TVF that returns a dataclass fails when it is decorated:

from __future__ import annotations

@dataclass
class Row:
    a: int
    b: Optional[str] = None

@udf
def f(x: int) -> Table[List[Row]]: ...
# TypeError: unrecognized data type: int

get_dataclass_schema takes the dtype from dataclasses.fields(obj)[i].type, which is still the string 'int' here. The NamedTuple and TypedDict paths resolve their annotations already, which is why they work. This never worked on main either (KeyError: 'int' from get_signature). The difference is that the module used to import, and with this PR's decoration-time check the import itself fails.

Fix

Resolve the field types with utils.get_annotations (typing.get_type_hints), the same thing get_typeddict_schema uses. get_type_hints only adds an implicit Optional for function defaults, not class fields, so a field such as b: str = None keeps its type.

Verification (against 8f8fd55)

  • The new test fails without the fix (TypeError: unrecognized data type: int) and passes with it.
  • I ran 352 annotation and override cases (176 forms, each with and without from __future__ import annotations), plus the 94 UDFs in tests/ext_funcs, before and after the fix. Exactly three results changed, all under postponed annotations:
    • A dataclass TVF now works.
    • returns= set to a dataclass now works.
    • A dataclass with a Decimal field now raises decimal.Decimal is not supported for UDF annotations, not unrecognized data type: int.
  • The UDF and plugin unit tests pass, and flake8 is clean on both files.
Patch (applies to 8f8fd55 with git apply)
diff --git a/singlestoredb/functions/signature.py b/singlestoredb/functions/signature.py
index 0ce05a7e..2d4e8916 100644
--- a/singlestoredb/functions/signature.py
+++ b/singlestoredb/functions/signature.py
@@ -577,10 +577,11 @@ def get_dataclass_schema(obj: Any) -> List[ParamSpec]:
         A list of parameter specifications for the dataclass fields
 
     """
+    hints = utils.get_annotations(obj)
     return [
         ParamSpec(
             name=f.name,
-            dtype=f.type,
+            dtype=hints[f.name],
             default=NO_DEFAULT if f.default is dataclasses.MISSING else f.default,
         )
         for f in dataclasses.fields(obj)
diff --git a/singlestoredb/tests/test_udf_future_annotations.py b/singlestoredb/tests/test_udf_future_annotations.py
index 296540ef..98873974 100644
--- a/singlestoredb/tests/test_udf_future_annotations.py
+++ b/singlestoredb/tests/test_udf_future_annotations.py
@@ -3,6 +3,7 @@
 """UDF signatures in a module using postponed evaluation of annotations."""
 from __future__ import annotations
 
+import dataclasses
 import datetime
 import unittest
 from datetime import date
@@ -31,6 +32,12 @@ class Row(NamedTuple):
     b: Optional[str]
 
 
+@dataclasses.dataclass
+class DataclassRow:
+    a: int
+    b: Optional[str] = None
+
+
 class TestFutureAnnotations(unittest.TestCase):
 
     def test_scalars(self):
@@ -54,6 +61,13 @@ class TestFutureAnnotations(unittest.TestCase):
             '`foo`(`x` BIGINT NOT NULL) ' \
             'RETURNS TABLE(`a` BIGINT NOT NULL, `b` TEXT NULL)'
 
+    def test_dataclass_table(self):
+        @udf
+        def foo(x: int) -> Table[List[DataclassRow]]: ...
+        assert to_sql(foo) == \
+            '`foo`(`x` BIGINT NOT NULL) ' \
+            'RETURNS TABLE(`a` BIGINT NOT NULL, `b` TEXT NULL)'
+
     def test_dtype_strings(self):
         @udf
         def foo(x: 'str') -> 'array[int64]': ...  # noqa: F821

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Quoted annotations remain unresolved after a single evaluation when postponed annotations are enabled, causing @udf to raise TypeError for otherwise supported signatures.

1 open issue
1 resolved since last review

Review effort: Balanced

Comment thread singlestoredb/functions/signature.py Resolved
@ShubMishra
ShubMishra force-pushed the skumar/MCDB-100032-reject-unsupported-udf-types branch from 8f8fd55 to fe1e30e Compare November 4, 2026 06:59
@ShubMishra
ShubMishra requested a balanced review from Copilot November 4, 2026 06:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Annotation handling still drops NULL masks at runtime and rejects valid TVF dtype strings and nested forward references, causing incorrect execution or signature-generation failures.

3 open issues
1 resolved since last review

Review effort: Balanced

Comment thread singlestoredb/functions/signature.py Resolved
Comment thread singlestoredb/functions/signature.py Resolved
Comment thread singlestoredb/functions/signature.py Resolved
Reject date, time, datetime, timestamp, year, and decimal annotations and SQL type overrides at both @udf decoration time and signature extraction before emitting DDL that SingleStore rejects with ERROR 1706. Also resolve postponed string annotations in function signatures, dataclass schemas, and masked-parameter detection.
@ShubMishra
ShubMishra force-pushed the skumar/MCDB-100032-reject-unsupported-udf-types branch from fe1e30e to 82e7df6 Compare November 4, 2026 07:36

@kesmit13 kesmit13 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type annotation work is very fiddly code, so we'll want to watch for regressions. Most people are doing pretty basic things with it, so hopefully we won't see any issues.

@kesmit13
kesmit13 merged commit 22606bd into main Nov 4, 2026
14 checks passed
@kesmit13
kesmit13 deleted the skumar/MCDB-100032-reject-unsupported-udf-types branch November 4, 2026 14:21

This branch was successfully deployed

1 active deployment
Base — a2d6da94 Deployed Nov 4, 2026 by kesmit13 via test-coverage #618
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants